feat(gmail): forward original attachments and preserve inline images#554
feat(gmail): forward original attachments and preserve inline images#554malob wants to merge 1 commit intogoogleworkspace:mainfrom
Conversation
Include original message attachments on +forward by default, matching Gmail web behavior. Add --no-original-attachments flag to opt out (skips file attachments but preserves inline images in HTML mode). Preserve cid: inline images in HTML mode for both +forward and +reply/+reply-all by building the correct multipart/related MIME structure via mail-builder's MimePart API. Gmail's API rewrites Content-Disposition: inline to attachment in multipart/mixed, so explicit multipart/related is required. In plain-text mode, inline images are not included for both forward and reply, matching Gmail web behavior. Key implementation details: - Single-pass MIME payload walker replaces separate text/html extractors - OriginalPart metadata type with lazy attachment data fetching - Part classification uses Content-Disposition to distinguish regular attachments from inline images (some clients set Content-ID on both) - Content-ID and content_type sanitized against CRLF header injection - Size preflight before downloading original attachments - Remote filename sanitization (not rejection) for sender-controlled names - Walker does not recurse into hydratable parts (e.g., message/rfc822)
🦋 Changeset detectedLatest commit: a245f57 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request significantly enhances the Gmail composition helpers by bringing them closer to feature parity with the Gmail web interface. It introduces automatic inclusion of original message attachments when forwarding and ensures that inline images are properly preserved in HTML-based replies and forwards. These changes streamline the user experience by reducing manual steps and improving the fidelity of forwarded and replied messages, while also incorporating robust handling for MIME structure, security, and attachment size limits. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
There was a problem hiding this comment.
Code Review
This pull request introduces significant improvements to Gmail forwarding and replying functionality, specifically by automatically including original attachments and preserving inline images. The implementation includes a robust single-pass MIME payload walker, careful handling of content IDs and dispositions, and important security measures like sanitizing sender-controlled headers and filenames. Extensive test coverage has been added, demonstrating a thorough approach to the new features and edge cases. The changes are well-structured and enhance the parity with Gmail web behavior.
| for part in parts { | ||
| let data = fetch_attachment_data(client, token, message_id, &part.attachment_id).await?; | ||
|
|
||
| actual_bytes += data.len() as u64; | ||
| if actual_bytes > MAX_TOTAL_ATTACHMENT_BYTES { | ||
| return Err(GwsError::Validation(format!( | ||
| "Total attachment size exceeds {}MB limit (after downloading '{}')", | ||
| MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024), | ||
| part.filename, | ||
| ))); | ||
| } | ||
| return None; | ||
|
|
There was a problem hiding this comment.
The fetch_original_parts function currently fetches original attachments sequentially. For messages containing a large number of attachments, this sequential processing can introduce significant latency, negatively impacting the user experience. Consider fetching these attachments concurrently to improve performance.
For example, you could collect all fetch_attachment_data calls into a vector of futures and then use futures::future::join_all or futures::stream::FuturesUnordered to await them in parallel. This would allow network requests for multiple attachments to proceed simultaneously, reducing the total time spent waiting for downloads to complete.
There was a problem hiding this comment.
Parallel fetching was considered during design, but sequential was chosen for a couple of reasons:
- Most forwards have 1-3 attachments — the latency savings from parallelizing a handful of requests against the same API endpoint are marginal (bandwidth-bound, not round-trip-bound).
- Sequential enables simple early exit on size limit — cumulative bytes are checked after each download, bailing immediately on excess. Parallel fetching would download everything before discovering the limit was exceeded, potentially wasting significant bandwidth.
If this becomes a bottleneck for messages with many attachments, parallel fetching with a concurrency limit would be a good follow-up.
Description
Two features that close the biggest remaining Gmail web parity gaps for the
composition helpers:
1. Forward includes original attachments by default
+forwardnow fetches and includes the original message's file attachmentsautomatically, matching Gmail web. Previously, only user-supplied
--attachfiles were included.
--no-original-attachmentsflag to opt out (skips file attachments butpreserves inline images in HTML mode — "attachments" means files in the
attachment bar, not embedded body images)
before downloading, with a secondary actual-bytes check after each download
"Fetching N original attachment(s) (X.X MB)..."on stderr2. Inline images preserved via
multipart/relatedHTML-mode
+forwardand+reply/+reply-allnow preserve inline images(
cid:references) by building the correctmultipart/relatedMIME structure.Why this is required: Gmail's API actively rewrites
Content-Disposition: inlinetoContent-Disposition: attachmentwhen inline parts sit inmultipart/mixed(SO #38155144).The high-level
mail_builder::MessageBuilder::inline()API createsmultipart/mixed, so we use the lower-levelMimePartAPI to build thecorrect structure:
Plain-text mode: Inline images are not included, matching Gmail web
behavior (confirmed empirically — Gmail web strips inline images entirely from
plain-text forwards and replies rather than downgrading them to attachments).
Implementation details
Single-pass MIME payload walker (
extract_payload_contents) replaces theprevious separate
extract_plain_text_body/extract_html_bodytree walks.Collects body text, HTML body, and attachment/inline part metadata in one pass.
Part classification uses
Content-DispositionbeforeContent-IDtodistinguish regular attachments from inline images. Some email clients set
Content-IDon regular file attachments, soContent-IDalone is notsufficient — parts with
Content-Disposition: attachmentare alwaysclassified as regular attachments regardless of
Content-ID.Security:
Content-IDvalues are sanitized viasanitize_control_chars(stripping CR/LF that could inject MIME headers through mail-builder's
MessageIdtype) andstrip_angle_brackets. Thecontent_typefield fromthe MIME payload receives the same treatment. Remote filenames are sanitized
(control chars stripped, fallback to synthesized name) rather than rejected.
Walker does not recurse into hydratable parts (e.g.,
message/rfc822attachments). An attached email's body and nested parts are not extracted
into the top-level message.
#[serde(skip_serializing)]onOriginalMessage.partspreventsattachment metadata from appearing in
+read --format jsonoutput.fetch_and_merge_original_partsshared helper used by both forward andreply handlers — takes
&reqwest::Clientand&str(notOption), so thetype system enforces that auth is present.
Behavioral summary
+forward(HTML)multipart/related)+forward(plain text)+forward --no-original-attachments(HTML)multipart/related)+forward --no-original-attachments(plain text)+reply/+reply-all(HTML)multipart/related)+reply/+reply-all(plain text)Live testing
Tested against a real message containing both an inline image (
image/jpegwith
Content-ID,Content-Disposition: inline) and a regular PDF attachment(
Content-Disposition: attachment). All 12 behavioral paths verified byinspecting the sent messages' raw MIME structure via the Gmail API:
multipart/mixed>multipart/related(html + inline jpg) + pdfDisp=inline--no-original-attachmentsmultipart/related(html + inline jpg), no pdf--attach+ originalsmultipart/mixed(text + pdf), no inline jpg--no-original-attachmentstext/plainonlymultipart/related(html + inline jpg), no pdftext/plainonlytext/plainonly (regression)--dry-runTest coverage
231 Gmail tests (53 new), 778 total. New tests cover:
filename synthesis, Content-ID normalization, CRLF injection sanitization,
case-insensitive headers, control-char filename sanitization, attachment with
Content-ID +
Content-Disposition: attachmentclassified correctly,message/rfc822subtree not recursed intofinalize_messageMIME structure:multipart/relatedonly, mixed + related,multiple inline images, plain-text downgrade, HTML without inline
--no-original-attachmentsflag parsing, filter matrix (4 tests covering all html × flag × inline combinations)
multipart/relatedparse_original_messageend-to-end with parts populatedsynthesize_filenamespecial cases,sanitize_remote_filenameedge casesChecklist:
AGENTS.mdguidelines (no generatedgoogle-*crates).cargo fmt --allto format the code perfectly.cargo clippy -- -D warningsand resolved all warnings.pnpx changeset) to document my changes.